HEX
Server: Apache/2.4.68 (Debian)
System: Linux as-cs-widget-demo-us-central1 6.1.0-44-cloud-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.164-1 (2026-03-09) x86_64
User: root (0)
PHP: 8.2.32
Disabled: NONE
Upload Files
File: /var/www/kevin-demo/wp-content/plugins/allspice/includes/content-enforce.php
<?php
// includes/content-enforce.php
//
// Whole-post/page enforcement for the synced content-access rules (content-gates.php):
//   content_immediate - the entire the_content result is replaced by a server-rendered gate.
//   content_preview  - a safe preview (manual excerpt, else plain-text 55-word fallback) is
//                       shown, followed by the gate.
//
// Protected content is never present in unauthorized HTML - no CSS/JS hiding, anywhere. The
// enforcement filter runs at the_content priority 0, before shortcode/embed/block expansion,
// so protected shortcodes, embeds and scripts never execute for an unauthorized reader.
// Secondary surfaces (feeds, REST, excerpts, oEmbed-via-excerpt, WPRM print + JSON-LD) are
// covered with their own supported hooks - no output buffering, no arbitrary HTML deletion.
//
// Authorization: the verified first-party member session must contain any of the required
// canonical product ids (OR rule), same authority as every other gate.

if (!defined('ABSPATH')) exit;

/* ---------------------------------------------------------------------------- gate lookup */

/*
 * The active whole-content rule for a post, or null. Returns null (no enforcement) for:
 * excluded posts (membership landing page, developer filter - handled inside
 * allspice_content_gate_for_post), password-protected content (WordPress owns that flow),
 * and WordPress editors who can edit the post (previews must work).
 */
function allspice_content_enforcement_rule($post_id) {
    $post_id = (int)$post_id;
    if ($post_id <= 0) return null;
    /* Fail OPEN: a stale content-gate snapshot must never hide content once memberships are
       disabled or not ready. */
    if (!function_exists('allspice_gate_memberships_active') || !allspice_gate_memberships_active()) return null;
    if (!function_exists('allspice_content_gate_for_post')) return null;
    $rule = allspice_content_gate_for_post($post_id);
    if (!is_array($rule) || empty($rule['product_ids'])) return null;
    if (function_exists('post_password_required') && post_password_required($post_id)) return null;
    if (function_exists('current_user_can') && current_user_can('edit_post', $post_id)) return null;
    return $rule;
}

/* OR rule against the verified member session. */
function allspice_content_rule_authorized(array $rule): bool {
    $ids = isset($rule['product_ids']) && is_array($rule['product_ids']) ? $rule['product_ids'] : [];
    if ($ids === []) return false;
    return function_exists('allspice_member_has_any_product') && allspice_member_has_any_product($ids);
}

/*
 * True when THIS request renders a whole-content gate (unauthorized reader on a gated
 * singular). membership-gate.php consults this so the recipe-card gate never renders
 * alongside/inside a whole-content gate (Part 4).
 */
function allspice_content_gate_overrides_recipe(): bool {
    if (!is_singular() || is_admin() || is_feed()) return false;
    $rule = allspice_content_enforcement_rule((int)get_queried_object_id());
    if ($rule === null) return false;
    return !allspice_content_rule_authorized($rule);
}

/* ---------------------------------------------------------------------------- debugging */

/*
 * Admin-only gate diagnostics: append ?allspice_gate_debug=1 to any frontend URL while
 * logged in as an administrator and the request returns the exact whole-content decision
 * inputs as JSON instead of the page. Runs on template_redirect (before ANY theme code), so
 * it works even on themes whose templates never call the_content or wp_footer. Safe to keep:
 * requires manage_options, and exposes only public config + the admin's own access state.
 */
add_action('template_redirect', 'allspice_content_gate_debug', 1);
function allspice_content_gate_debug(): void {
    if (!isset($_GET['allspice_gate_debug'])) return;
    if (!function_exists('current_user_can') || !current_user_can('manage_options')) return;
    $id = is_singular() ? (int)get_queried_object_id() : 0;
    $permalink = $id > 0 ? (string)get_permalink($id) : '';
    $rule = $id > 0 ? allspice_content_gate_for_post($id) : null;
    wp_send_json([
        'is_singular' => is_singular(),
        'post_id' => $id,
        'post_type' => $id > 0 ? get_post_type($id) : null,
        'permalink' => $permalink,
        'url_key' => ($permalink !== '' && function_exists('allspice_content_gate_key'))
            ? allspice_content_gate_key($permalink) : null,
        'stored_gate_keys' => function_exists('allspice_content_gates_get')
            ? array_keys(allspice_content_gates_get()) : null,
        'rule_for_this_post' => $rule,
        'memberships_configured' => function_exists('allspice_memberships_configured')
            ? allspice_memberships_configured() : null,
        'viewer_is_editor_bypassed' => $id > 0 && current_user_can('edit_post', $id),
        'rule_after_editor_bypass' => $id > 0 ? allspice_content_enforcement_rule($id) : null,
        'viewer_member_authorized' => is_array($rule) ? allspice_content_rule_authorized($rule) : null,
    ]);
}

/* ---------------------------------------------------------------------------- previews */

/*
 * SAFE preview text (content_preview). Manual WordPress excerpt when authored; otherwise a
 * plain-text preview generated from RAW post_content WITHOUT ever running it through
 * apply_filters('the_content') - blocks, shortcodes, embeds, scripts, forms, recipe cards and
 * schema never execute. Truncation ends with an ellipsis.
 */
function allspice_content_preview_text($post): string {
    $post = is_object($post) ? $post : (function_exists('get_post') ? get_post($post) : null);
    if (!$post) return '';
    $manual = trim((string)($post->post_excerpt ?? ''));
    if ($manual !== '') {
        return trim(wp_strip_all_tags($manual, true));
    }
    $raw = (string)($post->post_content ?? '');
    /* Block content: keep only text-safe freeform content (drops embeds, forms, recipe-card
       and interactive blocks) - core's own excerpt-safe reducer. */
    if (function_exists('excerpt_remove_blocks')) {
        $raw = excerpt_remove_blocks($raw);
    } else {
        $raw = preg_replace('/<!--\s*\/?wp:.*?-->/s', '', $raw);
    }
    /* Shortcodes must not execute OR leak their inner syntax. */
    if (function_exists('strip_shortcodes')) {
        $raw = strip_shortcodes($raw);
    }
    $raw = preg_replace('/\[[^\]\n]*\]/', '', $raw); // any leftover bracket syntax
    /* Scripts/styles/forms including their contents. */
    $raw = preg_replace('#<(script|style|form|iframe|object|embed|textarea|button)\b[^>]*>.*?</\1>#is', '', $raw);
    $raw = wp_strip_all_tags($raw, true);
    /* Bare URLs on their own line are classic-embed leftovers. */
    $lines = array_filter(array_map('trim', explode("\n", $raw)), static function ($line) {
        return $line !== '' && !preg_match('#^https?://\S+$#i', $line);
    });
    $text = trim(implode(' ', $lines));
    $limit = (int)apply_filters('allspice_content_preview_word_limit', 55, $post);
    if ($limit < 1) $limit = 55;
    return wp_trim_words($text, $limit, '…');
}

/* ------------------------------------------------------------------------------ gate HTML */

/* Gate copy strictly by gate type, from the ONE normalizer in membership.php (schema v2
   nested gate_ui / legacy gates[] both handled there). content_preview and content_immediate
   each get their own copy - never shared with the recipe-card gate, never gates[0]. */
function allspice_content_gate_copy(string $gate_type): array {
    return allspice_membership_gate_copy($gate_type);
}

/*
 * Full PHP/JS gate contract: the hydrator (page bundle) reads type, canonical product ids
 * (JSON array), url_key, config_version, and needs a real status target. One source for
 * every whole-content gate.
 */
function allspice_content_gate_html(array $rule, int $post_id): string {
    $gate_type = (string)($rule['gate_type'] ?? 'content_immediate');
    $products = isset($rule['product_ids']) && is_array($rule['product_ids'])
        ? array_values(array_map('strval', $rule['product_ids'])) : [];
    $permalink = function_exists('get_permalink') ? (string)get_permalink($post_id) : '';
    $url_key = $permalink !== '' && function_exists('allspice_content_gate_key')
        ? allspice_content_gate_key($permalink) : '';
    $cv = function_exists('allspice_memberships_config_version') ? allspice_memberships_config_version() : '';
    $c = allspice_content_gate_copy($gate_type);
    return '<div class="allspice-recipe-gate allspice-content-gate"'
        . ' data-allspice-gate-type="' . esc_attr($gate_type) . '"'
        . ' data-allspice-gate-products="' . esc_attr((string)wp_json_encode($products)) . '"'
        . ' data-allspice-url-key="' . esc_attr($url_key) . '"'
        . ' data-allspice-gate-config-version="' . esc_attr($cv) . '">'
        . '<div class="allspice-recipe-gate__inner">'
        . '<div class="allspice-recipe-gate__brand" data-allspice-gate-brand></div>'
        . '<h3 class="allspice-recipe-gate__title">' . esc_html($c['title']) . '</h3>'
        . '<p class="allspice-recipe-gate__copy">' . esc_html($c['copy']) . '</p>'
        . (function_exists('allspice_membership_benefits_html')
            ? allspice_membership_benefits_html(allspice_membership_benefits_limit())
            : '')
        . allspice_membership_gate_actions_html($c['join_label'], $c['login_label'])
        . '<div class="allspice-recipe-gate__status" data-allspice-gate-status aria-live="polite"></div>'
        . '</div></div>';
}

/* Short generic replacement for secondary surfaces (feeds, REST, excerpts). */
function allspice_content_members_only_text(): string {
    return (string)apply_filters('allspice_content_members_only_text', 'This content is for members.');
}

/* Item 2: a member currently READING unlocked whole-content gets a marker class, so the page
   bundle can reload once after logout and restore the server-rendered gate. */
add_filter('body_class', 'allspice_content_member_unlocked_body_class', 2);
function allspice_content_member_unlocked_body_class(array $classes): array {
    if (!is_singular() || is_admin()) return $classes;
    $rule = allspice_content_enforcement_rule((int)get_queried_object_id());
    if ($rule !== null && allspice_content_rule_authorized($rule)) {
        $classes[] = 'allspice-member-content-unlocked';
    }
    return $classes;
}

/* --------------------------------------------------------------------------- the_content */

/*
 * Priority 0: runs before wpautop/shortcodes/blocks, so for content_immediate the protected
 * body is REPLACED before anything inside it can expand or execute. Theme-rendered title,
 * featured image, author, date etc. live outside the_content and are untouched.
 */
add_filter('the_content', 'allspice_content_enforce_filter', 0);
function allspice_content_enforce_filter($content) {
    if (!is_string($content)) return $content;
    if (is_admin() || is_feed()) return $content;                    // feeds have their own hook
    if (!is_singular()) return $content;
    /* THEME COMPAT (live finding on the fitmencook `recipes` CPT template): custom singular
       templates often render the_content from a secondary query or outside the formal loop,
       so in_the_loop()/is_main_query() are false and a loop-state guard silently disables
       gating. The real invariant we need is IDENTITY: the content being filtered is the
       queried singular post's own content. Excerpt/related/widget passes carry a different
       current post id and still fall through untouched. */
    $post_id = (int)get_the_ID();
    $queried = (int)get_queried_object_id();
    if ($post_id <= 0 || $queried <= 0 || $post_id !== $queried) return $content;
    $rule = allspice_content_enforcement_rule($post_id);
    if ($rule === null) return $content;
    if (allspice_content_rule_authorized($rule)) return $content;    // member: untouched

    if (allspice_content_gate_mode() === 'overlay') {
        /* SOFT OVERLAY (product decision 2026-08-05, replacing removal as the default):
           the full content SHIPS in the HTML; the gate renders as a clamp + fade + overlay
           and the page bundle locks scroll when the gate reaches the viewport. This is a
           deliberate soft paywall - trivially bypassable via view-source/reader mode, and
           readable by crawlers (the JSON-LD below declares the lead-in paywall to Google).
           Publishers who need hard protection switch back with:
           add_filter('allspice_content_gate_mode', fn() => 'remove');
           Feeds, excerpts, and REST stay PROTECTED (removal) in both modes - no overlay
           exists on those surfaces. */
        return allspice_content_soft_gate_html($rule, $post_id, $content);
    }

    if ($rule['gate_type'] === 'content_preview') {
        $preview = allspice_content_preview_text(get_post($post_id));
        $preview_html = $preview !== '' ? '<p>' . esc_html($preview) . '</p>' : '';
        return $preview_html . "\n" . allspice_content_gate_html($rule, $post_id);
    }
    /* content_immediate: no article-body content before the gate. */
    return allspice_content_gate_html($rule, $post_id);
}

/* 'overlay' (default) | 'remove' (the original secure engine - content absent from HTML). */
function allspice_content_gate_mode(): string {
    $mode = apply_filters('allspice_content_gate_mode', 'overlay');
    return $mode === 'remove' ? 'remove' : 'overlay';
}

/*
 * Soft-overlay markup: clamped content (content_preview shows a tall lead-in, content_immediate
 * only a teaser sliver), a fade into the gate background, then the STANDARD gate card (same
 * copy/benefits/actions/hydrator contract as removal mode). The JSON-LD marks the clamped
 * section as paywalled lead-in content per Google's paywall guidance, so shipping the body
 * in HTML is not treated as cloaking.
 */
function allspice_content_soft_gate_html(array $rule, int $post_id, string $content): string {
    $gate_type = (string)($rule['gate_type'] ?? 'content_immediate');
    $variant = $gate_type === 'content_preview' ? 'preview' : 'immediate';
    $schema = [
        '@context' => 'https://schema.org',
        '@type' => 'Article',
        'isAccessibleForFree' => false,
        'hasPart' => [
            '@type' => 'WebPageElement',
            'isAccessibleForFree' => false,
            'cssSelector' => '.allspice-paywalled-content',
        ],
    ];
    return (function_exists('allspice_gate_embedded_css') ? allspice_gate_embedded_css() : '')
        . '<div class="allspice-soft-gate allspice-soft-gate--' . esc_attr($variant) . '"'
        . ' data-allspice-soft-gate="' . esc_attr($gate_type) . '">'
        . '<div class="allspice-soft-gate__content allspice-paywalled-content">' . $content . '</div>'
        . '<div class="allspice-soft-gate__fade" aria-hidden="true"></div>'
        . allspice_content_gate_html($rule, $post_id)
        . '<script type="application/ld+json">' . wp_json_encode($schema) . '</script>'
        . '</div>';
}

/* ------------------------------------------------------------- secondary output surfaces */

/* Feeds: full-content feed + RSS excerpt. */
add_filter('the_content_feed', 'allspice_content_enforce_feed');
add_filter('the_excerpt_rss', 'allspice_content_enforce_feed');
function allspice_content_enforce_feed($content) {
    $post_id = (int)get_the_ID();
    $rule = allspice_content_enforcement_rule($post_id);
    if ($rule === null || allspice_content_rule_authorized($rule)) return $content;
    if ($rule['gate_type'] === 'content_preview') {
        $p = allspice_content_preview_text(get_post($post_id));
        return $p !== '' ? esc_html($p) : esc_html(allspice_content_members_only_text());
    }
    return esc_html(allspice_content_members_only_text());
}

/* Excerpts everywhere they surface (archives, search, related-post cards, oEmbed discovery). */
add_filter('get_the_excerpt', 'allspice_content_enforce_excerpt', 20, 2);
function allspice_content_enforce_excerpt($excerpt, $post = null) {
    $post_id = $post ? (int)(is_object($post) ? $post->ID : $post) : (int)get_the_ID();
    $rule = allspice_content_enforcement_rule($post_id);
    if ($rule === null || allspice_content_rule_authorized($rule)) return $excerpt;
    if ($rule['gate_type'] === 'content_preview') {
        $p = allspice_content_preview_text(function_exists('get_post') ? get_post($post_id) : null);
        return $p !== '' ? $p : allspice_content_members_only_text();
    }
    return allspice_content_members_only_text();
}

/* REST: posts + pages. Editors keep raw access; everyone else gets the gated shapes. */
add_filter('rest_prepare_post', 'allspice_content_enforce_rest', 20, 3);
add_filter('rest_prepare_page', 'allspice_content_enforce_rest', 20, 3);
function allspice_content_enforce_rest($response, $post = null, $request = null) {
    if (!is_object($response) || !isset($response->data) || !is_array($response->data)) return $response;
    $post_id = (int)(is_object($post) ? ($post->ID ?? 0) : 0);
    $rule = allspice_content_enforcement_rule($post_id);   // editors already excluded inside
    if ($rule === null || allspice_content_rule_authorized($rule)) return $response;
    $replacement = $rule['gate_type'] === 'content_preview'
        ? allspice_content_preview_text(function_exists('get_post') ? get_post($post_id) : $post)
        : allspice_content_members_only_text();
    if ($replacement === '') $replacement = allspice_content_members_only_text();
    if (isset($response->data['content']) && is_array($response->data['content'])) {
        $response->data['content']['rendered'] = '<p>' . esc_html($replacement) . '</p>';
        if (array_key_exists('raw', $response->data['content'])) $response->data['content']['raw'] = '';
        $response->data['content']['protected'] = true;
    }
    if (isset($response->data['excerpt']) && is_array($response->data['excerpt'])) {
        $response->data['excerpt']['rendered'] = '<p>' . esc_html($replacement) . '</p>';
        $response->data['excerpt']['protected'] = true;
    }
    return $response;
}

/*
 * WPRM surfaces for a whole-content-gated recipe URL (best-effort; WPRM is third-party and
 * not installed here - these are its documented public hooks, live-verification pending):
 *  - JSON-LD: `wprm_recipe_metadata` -> [] removes the recipe schema for unauthorized readers.
 *  - Print view: WPRM's print template runs through template_redirect with its own query;
 *     when the parent post is whole-content gated, send the reader to the post instead.
 */
add_filter('wprm_recipe_metadata', 'allspice_content_enforce_recipe_schema', 20, 2);
function allspice_content_enforce_recipe_schema($metadata, $recipe = null) {
    if (!allspice_content_gate_overrides_recipe()) return $metadata;
    return [];
}

/*
 * WPRM print. Request shape (WPRM docs/source; LIVE VERIFICATION STILL PENDING - no WPRM
 * install exists in this repo): pretty URL `/wprm_print/{recipe_id}` (optionally with a slug
 * segment) or `?wprm_print={recipe_id}`, registered via WPRM's `wprm_print` rewrite/query
 * var. That request is not the parent post's singular view - is_singular() checks say
 * nothing there, so the parent is resolved DETERMINISTICALLY: the numeric recipe id from the
 * query var / URI -> the wprm_recipe post -> its post_parent -> that post's content rule.
 */
function allspice_wprm_print_recipe_id(): int {
    $raw = '';
    if (isset($_GET['wprm_print'])) $raw = (string)$_GET['wprm_print'];
    if ($raw === '' && function_exists('get_query_var')) $raw = (string)get_query_var('wprm_print');
    if ($raw === '' && isset($_SERVER['REQUEST_URI'])
        && preg_match('#/wprm_print/(?:[^/]+/)?(\d+)#', (string)$_SERVER['REQUEST_URI'], $m)) {
        $raw = $m[1];
    }
    if (preg_match('/(\d+)/', $raw, $m)) return (int)$m[1];
    return 0;
}

function allspice_wprm_print_parent_post_id(): int {
    $recipe_id = allspice_wprm_print_recipe_id();
    if ($recipe_id <= 0 || !function_exists('get_post')) return 0;
    $recipe = get_post($recipe_id);
    if (!$recipe) return 0;
    /* WPRM stores recipes as wprm_recipe posts whose post_parent is the article. */
    $parent = (int)($recipe->post_parent ?? 0);
    if ($parent > 0) return $parent;
    /* Fallback: WPRM also records the parent on the recipe's meta. */
    if (function_exists('get_post_meta')) {
        $meta = (int)get_post_meta($recipe_id, 'wprm_parent_post_id', true);
        if ($meta > 0) return $meta;
    }
    return 0;
}

add_action('template_redirect', 'allspice_content_enforce_wprm_print', 1);
function allspice_content_enforce_wprm_print(): void {
    $recipe_id = allspice_wprm_print_recipe_id();
    if ($recipe_id <= 0) return;
    $parent_id = allspice_wprm_print_parent_post_id();
    if ($parent_id <= 0) return; // cannot identify the parent deterministically: do nothing
    $rule = allspice_content_enforcement_rule($parent_id);
    if ($rule === null || allspice_content_rule_authorized($rule)) return;
    $target = function_exists('get_permalink') ? get_permalink($parent_id) : home_url('/');
    wp_safe_redirect($target ?: home_url('/'));
    exit;
}